11.1 Kreuzvalidierung#
In der Praxis ist es entscheidend, dass ein ML-Modell nicht nur gute Prognosen für die Daten liefert, sondern auch für neue, unbekannte Daten zuverlässig funktioniert. Durch das Aufteilen der Daten in Trainings- und Testdaten können wir eine erste Einschätzung über die Verallgemeinerungsfähigkeit eines Modells treffen. Dieser Ansatz weist jedoch einige Schwächen auf, die wir in diesem Kapitel näher beleuchten. Im Anschluss lernen wir ein fortschrittlicheres Verfahren kennen: die Kreuzvalidierung, die über die einfache Aufteilung in Trainings- und Testdaten hinausgeht und eine robustere Bewertung der Modellleistung ermöglicht.
Lernziele#
Lernziele
Sie sind in der Lage, das Konzept der Kreuzvalidierung (Cross Validation) verständlich zu erklären.
Sie können die Vor- und Nachteile der Kreuzvalidierung aufzählen und bewerten.
Sie können mit KFold einen Datensatz in verschiedene Teilmengen (Folds) aufteilen.
Sie beherrschen die Durchführung einer Kreuzvalidierung mithilfe der Funktion cross_validate().
Idee der Kreuzvalidierung#
Ein zentraler Schritt im ML-Workflow ist die Aufteilung der Daten in einen Trainings- und einen Testdatensatz. Das Modell wird auf den Trainingsdaten trainiert und anschließend auf den Testdaten bewertet. Diese Methode hat jedoch auch Nachteile. Besonders bei kleinen Datensätzen ist es problematisch, beispielsweise 25 % der Daten für den Test zurückhalten zu müssen, da dies die Datenmenge für das Training reduziert. Zudem kann eine zufällige Aufteilung der Daten zu unbalancierten Splits führen, die die Trainings- und Testergebnisse verfälschen. Eine sinnvolle Alternative zu dieser simplen Aufteilung ist die Kreuzvalidierung (engl. Cross Validation).
Bei der Kreuzvalidierung werden die Daten in mehrere Teilmengen, sogenannte Folds, aufgeteilt. Beispielsweise können die Daten in fünf Folds unterteilt werden. Das Modell wird dann fünfmal trainiert und getestet, wobei in jedem Durchlauf eine andere Teilmenge als Testdaten verwendet wird. Im ersten Durchlauf wird etwa Fold A für den Test zurückgehalten, während die Folds B, C, D und E zum Training genutzt werden. Im zweiten Durchlauf wird Fold B als Testdatensatz verwendet und die restlichen Folds dienen wieder dem Training. Dieser Prozess wird so lange wiederholt, bis jeder Fold einmal als Testdaten fungiert hat. Am Ende wird die Modellleistung (Score) als Durchschnitt der Ergebnisse aus den fünf Durchläufen berechnet.
Es müssen jedoch nicht zwingend fünf Folds verwendet werden. Oftmals werden die Daten in zehn Folds aufgeteilt, sodass 90 % der Daten zum Training und 10 % für den Test verwendet werden. Ein weiterer Vorteil ist, dass jeder Datenpunkt im Laufe der Kreuzvalidierung sowohl im Training als auch im Test berücksichtigt wird, jedoch nie gleichzeitig. Dies verringert die Gefahr, dass unausgewogene Daten zu verzerrten Testergebnissen führen, wie es bei einer zufälligen Aufteilung passieren könnte.
Zusammengefasst bietet die Kreuzvalidierung mehrere Vorteile:
Effizientere Datennutzung: Jeder Datenpunkt wird mindestens einmal als Testdatenpunkt verwendet, was besonders bei kleinen Datensätzen wichtig ist, da die Daten optimal ausgenutzt werden.
Stabilere Schätzung der Modellleistung: Durch das wiederholte Training und Testen auf verschiedenen Daten erhöht sich die Robustheit der geschätzten Modellleistung (Score), da zufällige Verzerrungen durch unbalancierte Splits minimiert werden.
Ein Nachteil der Kreuzvalidierung ist der erhöhte Rechenaufwand, da das Modell mehrfach trainiert und getestet wird.
Können wir also auf die Aufteilung in Trainings- und Testdaten verzichten? Nein, denn für das Hyperparameter-Tuning ist der Split weiterhin notwendig. Mehr dazu im nächsten Kapitel. Zunächst widmen wir uns der praktischen Umsetzung der Kreuzvalidierung in Scikit-Learn.
Kreuzvalidierung mit KFold#
Um die Kreuzvalidierung in Scikit-Learn zu demonstrieren, generieren wir
zunächst einen künstlichen Datensatz. Mithilfe der Funktion make_moons()
erstellen wir 50 Datenpunkte und speichern sie in einem Pandas-DataFrame. Für
eine einfachere Visualisierung mit Plotly Express wandeln wir die Zielvariable
'Wirkung' von den Werten 0/1 in boolesche Werte (False/True) um.
import pandas as pd
import plotly.express as px
from sklearn.datasets import make_moons
X_array, y_array = make_moons(noise = 0.5, n_samples=50, random_state=3)
daten = pd.DataFrame({
'Merkmal 1': X_array[:,0],
'Merkmal 2': X_array[:,1],
'Wirkung': y_array
})
daten['Wirkung'] = daten['Wirkung'].astype('bool')
fig = px.scatter(daten, x = 'Merkmal 1', y = 'Merkmal 2', color='Wirkung',
title='Künstliche Daten')
fig.show()
Als Nächstes laden wir die Klasse KFold aus dem Untermodul
sklearn.model_selection. Wir instanziieren ein KFold-Objekt mit dem Argument
n_splits=5, das die Daten in fünf Teilmengen (Folds) aufteilt. Tatsächlich ist
dies die Standardeinstellung, wie uns die Dokumentation Scikit-Learn →
KFold
zeigt. Das Argument könnte also weggelassen werden.
from sklearn.model_selection import KFold
kfold = KFold(n_splits = 5)
Im Hintergrund wurde ein Generator erzeugt, mit Hilfe dessen wir Daten in fünf
Teilmengen (Folds) aufteilen können. Dazu benutzen wir die Methode .split()
und übergeben ihr die Daten, die gesplittet werden sollen.
kfold.split(daten)
<generator object _BaseKFold.split at 0x10823af00>
Zwar wurde hiermit die Aufteilung in fünf Teilmengen vollzogen, doch die
eigentlichen Trainings- und Testdaten wurden noch nicht gespeichert und
weiterverarbeitet. Mithilfe einer for-Schleife greifen wir in jedem Durchgang
auf die Trainings- und Testindizes zu, die die Methode split() als Tupel
zurückgibt. Das erste Element enthält die Indizes der Trainingsdaten, das zweite
die der Testdaten.
for (train_index, test_index) in kfold.split(daten):
print(f'Index Trainingsdaten: {train_index}')
print(f'Index Testdaten: {test_index}')
Index Trainingsdaten: [10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49]
Index Testdaten: [0 1 2 3 4 5 6 7 8 9]
Index Trainingsdaten: [ 0 1 2 3 4 5 6 7 8 9 20 21 22 23 24 25 26 27 28 29 30 31 32 33
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49]
Index Testdaten: [10 11 12 13 14 15 16 17 18 19]
Index Trainingsdaten: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 30 31 32 33
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49]
Index Testdaten: [20 21 22 23 24 25 26 27 28 29]
Index Trainingsdaten: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
24 25 26 27 28 29 40 41 42 43 44 45 46 47 48 49]
Index Testdaten: [30 31 32 33 34 35 36 37 38 39]
Index Trainingsdaten: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39]
Index Testdaten: [40 41 42 43 44 45 46 47 48 49]
Die Aufteilung der Daten erfolgt hierbei sehr systematisch. Im ersten Durchgang
werden die Datenpunkte 0–9 als Testdaten verwendet, im zweiten Durchgang die
Punkte 10–19 und so weiter. Bei sortierten Daten kann dies ungünstig sein. Um
eine zufällige Aufteilung zu gewährleisten, können wir das Argument
shuffle=True verwenden, um die Daten vor dem Split zu mischen.
kfold = KFold(n_splits = 5, shuffle=True)
for (train_index, test_index) in kfold.split(daten):
print(f'Index Trainingsdaten: {train_index}')
print(f'Index Testdaten: {test_index}')
Index Trainingsdaten: [ 0 1 2 3 4 5 6 8 12 13 14 15 17 18 19 20 21 23 24 25 26 27 29 30
31 32 33 34 35 36 37 38 41 42 44 45 46 47 48 49]
Index Testdaten: [ 7 9 10 11 16 22 28 39 40 43]
Index Trainingsdaten: [ 0 2 4 5 6 7 8 9 10 11 12 13 14 15 16 17 20 21 22 23 24 26 27 28
30 31 32 33 34 35 37 38 39 40 41 42 43 45 47 49]
Index Testdaten: [ 1 3 18 19 25 29 36 44 46 48]
Index Trainingsdaten: [ 1 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 27
28 29 30 32 34 36 37 39 40 41 42 43 44 45 46 48]
Index Testdaten: [ 0 2 6 26 31 33 35 38 47 49]
Index Trainingsdaten: [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 16 18 19 22 23 25 26 27 28 29
30 31 33 34 35 36 37 38 39 40 43 44 46 47 48 49]
Index Testdaten: [14 15 17 20 21 24 32 41 42 45]
Index Trainingsdaten: [ 0 1 2 3 6 7 9 10 11 14 15 16 17 18 19 20 21 22 24 25 26 28 29 31
32 33 35 36 38 39 40 41 42 43 44 45 46 47 48 49]
Index Testdaten: [ 4 5 8 12 13 23 27 30 34 37]
Nun verwenden wir diese fünf Aufteilungen, um einen Entscheidungsbaum zu trainieren. Dabei begrenzen wir die Baumtiefe auf 3 und bewerten in jedem Durchgang die Genauigkeit (Score) sowohl auf den Trainings- als auch auf den Testdaten.
from sklearn.tree import DecisionTreeClassifier
modell = DecisionTreeClassifier(max_depth=3)
kfold = KFold(n_splits = 5, shuffle=True, random_state=0)
for (train_index, test_index) in kfold.split(daten):
X_train = daten.loc[train_index, ['Merkmal 1', 'Merkmal 2']]
y_train = daten.loc[train_index, 'Wirkung']
X_test = daten.loc[test_index, ['Merkmal 1', 'Merkmal 2']]
y_test = daten.loc[test_index, 'Wirkung']
modell.fit(X_train, y_train)
score_train = modell.score(X_train, y_train)
score_test = modell.score(X_test, y_test)
print(f'Score Training: {score_train:.2f}, Score Test: {score_test:.2f}')
Score Training: 0.85, Score Test: 0.40
Score Training: 0.85, Score Test: 0.60
Score Training: 0.85, Score Test: 0.80
Score Training: 0.90, Score Test: 0.60
Score Training: 0.85, Score Test: 0.70
Die Scores auf den Trainingsdaten könnten den Eindruck erwecken, dass der
Entscheidungsbaum sehr gut funktioniert. Doch die Testdaten zeigen Schwankungen
zwischen 0.4 und 0.8. Hätten wir eine einfache Aufteilung in Trainings- und
Testdaten vorgenommen und zufällig den dritten Split erwischt, hätten wir
wahrscheinlich eine zu optimistische Einschätzung der Modellqualität getroffen.
Aus didaktischen Gründen verwenden wir das Argument random_state=0, um die
Ergebnisse mit dem Vorlesungsskript vergleichbar zu machen.
Automatische Kreuzvalidierung mit cross_validate#
Wie so oft bietet Scikit-Learn eine elegantere und einfachere Möglichkeit, die
Kreuzvalidierung (Cross Validation) durchzuführen, ohne manuell eine for-Schleife programmieren zu
müssen. Die Funktion cross_validate() übernimmt die Durchführung der
Kreuzvalidierung automatisch. Wir importieren sie aus dem Untermodul
sklearn.model_selection und teilen anschließend die Daten in Eingabedaten X
und Zielgröße y auf.
Die Funktion cross_validate() wird mit dem ML-Modell (hier einem
Entscheidungsbaum), den Eingabedaten X und der Zielgröße y aufgerufen.
Standardmäßig wird eine 5-fache Kreuzvalidierung ohne Mischen durchgeführt. Mit
dem optionalen Argument cv= kann jedoch auch ein benutzerdefinierter
Aufteilungsgenerator übergeben werden, wie zum Beispiel KFold. Das zusätzliche
Argument return_train_score=True sorgt dafür, dass auch die Trainingsscores in
jedem Durchlauf gespeichert werden. Der entsprechende Code sieht folgendermaßen
aus:
from sklearn.model_selection import cross_validate
X = daten[['Merkmal 1', 'Merkmal 2']]
y = daten['Wirkung']
cv_results = cross_validate(modell, X,y, cv=kfold, return_train_score=True)
Die Funktion cross_validate() gibt ein Dictionary zurück, das wie folgt
aufgebaut ist:
print(cv_results)
{'fit_time': array([0.00076914, 0.0008707 , 0.00053 , 0.00049996, 0.00049305]), 'score_time': array([0.00055599, 0.00052524, 0.00041604, 0.00040817, 0.00040507]), 'test_score': array([0.4, 0.6, 0.8, 0.6, 0.7]), 'train_score': array([0.85, 0.85, 0.85, 0.9 , 0.85])}
In diesem Dictionary sind zunächst die Rechenzeiten für das Training
('fit_time') und die Prognose ('score_time') gespeichert. Danach folgen die
Scores der Testdaten ('test_score'). Falls das Argument
return_train_score=True gesetzt wurde, enthält das Dictionary auch die Scores
der Trainingsdaten ('train_score'). Die Scores können wir wie folgt anzeigen
lassen:
print(cv_results['test_score'])
print(cv_results['train_score'])
[0.4 0.6 0.8 0.6 0.7]
[0.85 0.85 0.85 0.9 0.85]
Weitere Details zu der Funktion cross_validate() finden Sie in der
Dokumentation Scikit-Learn →
cross_validate.
Zusammenfassung und Ausblick#
Die Kreuzvalidierung ist ein wichtiges Werkzeug, insbesondere wenn es um die Feinjustierung der Hyperparameter geht, also das sogenannte Hyperparameter-Tuning. Im nächsten Kapitel werden wir uns mit der Kombination von Kreuzvalidierung (Cross Validation) und einer Gittersuche (Grid Search) beschäftigen, um die optimalen Hyperparameter für ein Modell zu finden.